[slug].astro raw
1 ---
2 export const prerender = false;
3 import Base from '../../layouts/Base.astro';
4 import { getProductBySlug, firstImage, parseImages } from '../../lib/products';
5 import { renderMarkdown } from '../../lib/markdown';
6
7 const product = getProductBySlug(Astro.params.slug!);
8 if (!product) {
9 return new Response(null, { status: 404, statusText: 'Not Found' });
10 }
11 const images = parseImages(product.images);
12 const mainImg = images[0] || '/images/logo.png';
13 const stock = product.stock_count;
14 const outOfStock = stock < 1;
15 const maxQty = Math.min(stock, 99);
16 ---
17
18 <Base title={product.name} titleEn={product.name_en}>
19 <main class="product-page">
20 <section class="section">
21 <div class="container">
22 <nav class="breadcrumbs">
23 <a href="/shop" data-i18n="backToShop">← Назад към магазина</a>
24 </nav>
25 <div class="product-detail">
26 <div class="product-detail-image">
27 <img src={mainImg} alt={product.name} id="main-image" class="gallery-main" />
28 {outOfStock && (
29 <span class="sold-out-badge" data-i18n="soldOut">Изчерпан</span>
30 )}
31 {images.length > 1 && (
32 <div class="gallery-thumbs">
33 {images.map((img, i) => (
34 <img src={img} alt={product.name} class:list={['gallery-thumb', { active: i === 0 }]} data-full={img} />
35 ))}
36 </div>
37 )}
38 <div id="admin-image-tools" style="display:none">
39 <div class="gallery-thumbs" id="admin-thumbs"></div>
40 <button type="button" class="ap-add-img" id="admin-upload-btn" title="Add image">
41 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
42 </button>
43 <input type="file" accept="image/*" id="admin-upload-input" style="display:none" />
44 </div>
45 </div>
46 <div class="product-detail-info">
47 <h1>{product.name}</h1>
48 {product.name_en && (
49 <p class="product-name-en">{product.name_en}</p>
50 )}
51 <p class="product-price-large">{(product.price_cents / 100).toFixed(2)} EUR</p>
52 <p class="product-weight"><span data-i18n="productWeight">Грамаж</span>: {product.unit_size} <span data-i18n="unitG">гр.</span></p>
53 {product.harvest_year && (
54 <p class="product-harvest"><span data-i18n="productHarvest">Реколта</span>: {product.harvest_year}</p>
55 )}
56 <p class="product-stock"><span data-i18n="productStock">Наличност</span>: {stock} <span data-i18n="productBroy">бр.</span></p>
57 {product.description && (
58 <div class="product-description md-content" id="product-description" data-bg={product.description} data-en={product.description_en || product.description}>
59 <h4 data-i18n="adminProdDesc">Описание</h4>
60 <div class="box-content"><Fragment set:html={renderMarkdown(product.description)} /></div>
61 </div>
62 )}
63 {product.usage && (
64 <div class="product-usage md-content" id="product-usage" data-bg={product.usage} data-en={product.usage_en || product.usage}>
65 <h4 data-i18n="productUsage">Препоръка за употреба</h4>
66 <div class="box-content"><Fragment set:html={renderMarkdown(product.usage)} /></div>
67 </div>
68 )}
69 <div class="product-actions">
70 <button class="btn btn-primary btn-lg" id="add-to-cart-btn"
71 data-slug={product.slug}
72 data-name={product.name}
73 data-name-en={product.name_en}
74 data-price={product.price_cents}
75 data-image={product.images}
76 data-unit-size={product.unit_size}
77 data-unit={product.unit}
78 data-stock={stock}
79 disabled={outOfStock}>
80 <span>{outOfStock ? 'Изчерпан' : 'ДОБАВИ В КОЛИЧКА'}</span>
81 </button>
82 </div>
83 </div>
84 </div>
85 </div>
86 </section>
87 </main>
88 </Base>
89
90 <script>
91 import { addToCart, getCart } from '../../lib/cart-client';
92 import { t, currentLang } from '../../lib/i18n';
93 import { renderMarkdown } from '../../lib/markdown';
94
95 const addBtn = document.getElementById('add-to-cart-btn')! as HTMLButtonElement;
96 const stock = parseInt(addBtn.dataset.stock!) || 0;
97
98 // language-aware description/usage boxes
99 const descEl = document.getElementById('product-description');
100 const usageEl = document.getElementById('product-usage');
101
102 function applyBoxLang() {
103 const lang = currentLang();
104 if (descEl) {
105 const md = lang === 'en' ? (descEl.dataset.en || descEl.dataset.bg!) : descEl.dataset.bg!;
106 const h4 = descEl.querySelector('h4');
107 const old = descEl.querySelector('.box-content');
108 old?.remove();
109 const div = document.createElement('div');
110 div.className = 'box-content';
111 div.innerHTML = renderMarkdown(md);
112 descEl.insertBefore(div, h4?.nextSibling || null);
113 }
114 if (usageEl) {
115 const md = lang === 'en' ? (usageEl.dataset.en || usageEl.dataset.bg!) : usageEl.dataset.bg!;
116 const h4 = usageEl.querySelector('h4');
117 const old = usageEl.querySelector('.box-content');
118 old?.remove();
119 const div = document.createElement('div');
120 div.className = 'box-content';
121 div.innerHTML = renderMarkdown(md);
122 usageEl.insertBefore(div, h4?.nextSibling || null);
123 }
124 }
125 applyBoxLang();
126 window.addEventListener('lang-changed', applyBoxLang);
127
128 // admin image tools: upload directly to site db
129 (async () => {
130 try {
131 const token = localStorage.getItem('token');
132 if (!token) return;
133 const r = await fetch('/api/auth/session', { headers: { 'Authorization': 'Bearer ' + token } });
134 const d = await r.json();
135 if (!d.user?.is_admin) return;
136 const tools = document.getElementById('admin-image-tools')!;
137 tools.style.display = 'block';
138 const slug = addBtn.dataset.slug!;
139 const prodList = await fetch('/api/products').then(r => r.json());
140 const product = Array.isArray(prodList) ? prodList.find((p: any) => p.slug === slug) : null;
141 if (!product) return;
142 let imgs: string[] = [];
143 try { imgs = JSON.parse(product.images || '[]'); } catch {}
144 const thumbs = document.getElementById('admin-thumbs')!;
145 function renderAdminThumbs() {
146 thumbs.innerHTML = imgs.map((img, i) => `<div style="position:relative;display:inline-block"><img src="${img}" class="gallery-thumb" style="width:64px;height:64px;object-fit:cover;border-radius:6px;border:2px solid transparent" /><button type="button" data-i="${i}" style="position:absolute;top:-6px;right:-6px;width:18px;height:18px;border-radius:9px;border:none;background:rgba(200,0,0,0.85);color:#fff;font-size:11px;cursor:pointer;line-height:1;padding:0">×</button></div>`).join('');
147 thumbs.querySelectorAll('button').forEach(b => {
148 b.addEventListener('click', async () => {
149 const i = parseInt((b as HTMLElement).dataset.i!);
150 imgs.splice(i, 1);
151 await saveImages(imgs);
152 renderAdminThumbs();
153 });
154 });
155 }
156 async function saveImages(list: string[]) {
157 const p: any = product;
158 await fetch(`/api/admin/products/${p.id}`, {
159 method: 'PUT',
160 headers: { 'Content-Type': 'application/json', ...(token ? { 'Authorization': 'Bearer ' + token } : {}) },
161 body: JSON.stringify({
162 name: p.name || '',
163 name_en: p.name_en || '',
164 slug: p.slug || '',
165 description: p.description || '',
166 description_en: p.description_en || '',
167 price_cents: p.price_cents,
168 unit_size: p.unit_size,
169 unit: p.unit || 'g',
170 images: JSON.stringify(list),
171 category: p.category || 'bilki',
172 stock_count: p.stock_count || 0,
173 harvest_year: p.harvest_year || null,
174 usage: p.usage || '',
175 usage_en: p.usage_en || '',
176 }),
177 });
178 }
179 renderAdminThumbs();
180 const uploadBtn = document.getElementById('admin-upload-btn')!;
181 const uploadInput = document.getElementById('admin-upload-input') as HTMLInputElement;
182 uploadBtn.addEventListener('click', () => uploadInput.click());
183 uploadInput.addEventListener('change', async () => {
184 const file = uploadInput.files?.[0];
185 if (!file) return;
186 const form = new FormData();
187 form.append('file', file);
188 const ur = await fetch('/api/admin/upload', { method: 'POST', headers: token ? { 'Authorization': 'Bearer ' + token } : {}, body: form });
189 const ud = await ur.json();
190 if (!ur.ok) { alert(ud.error || 'Upload failed'); return; }
191 if (ud.url) {
192 imgs.push(ud.url);
193 await saveImages(imgs);
194 renderAdminThumbs();
195 }
196 });
197 } catch {}
198 })();
199
200 function updateBtnState() {
201 if (stock < 1) return;
202 const cart = getCart();
203 const inCart = cart.find(it => it.slug === addBtn.dataset.slug!);
204 if (inCart && inCart.qty >= stock) {
205 addBtn.disabled = true;
206 addBtn.innerHTML = `<span data-i18n="noMoreAvailable">${t('noMoreAvailable')}</span>`;
207 } else {
208 addBtn.disabled = false;
209 addBtn.innerHTML = `<span data-i18n="addToCart">${t('addToCart')}</span>`;
210 }
211 }
212
213 updateBtnState();
214 window.addEventListener('cart-updated', updateBtnState);
215
216 addBtn.addEventListener('click', (e) => {
217 const btn = e.currentTarget as HTMLButtonElement;
218 if (btn.disabled) return;
219 addToCart({
220 slug: btn.dataset.slug!,
221 name: btn.dataset.name!,
222 name_en: btn.dataset.nameEn || btn.dataset.name!,
223 price_cents: parseInt(btn.dataset.price!),
224 image: btn.dataset.image!,
225 unit_size: parseInt(btn.dataset.unitSize!) || 100,
226 unit: btn.dataset.unit! || 'g',
227 stock: parseInt(btn.dataset.stock!) || 0,
228 }, 1);
229 btn.textContent = '✓';
230 setTimeout(() => updateBtnState(), 1000);
231 });
232
233 // gallery: thumbnail click swaps main image
234 const mainImg = document.getElementById('main-image')! as HTMLImageElement;
235 document.querySelectorAll('.gallery-thumb').forEach(thumb => {
236 thumb.addEventListener('click', () => {
237 const el = thumb as HTMLImageElement;
238 mainImg.src = el.dataset.full || el.src;
239 document.querySelectorAll('.gallery-thumb').forEach(t => t.classList.remove('active'));
240 el.classList.add('active');
241 });
242 });
243
244 // lightbox: click main image opens overlay
245 mainImg.addEventListener('click', () => {
246 let lb = document.getElementById('lightbox');
247 if (!lb) {
248 lb = document.createElement('div');
249 lb.id = 'lightbox';
250 lb.className = 'lightbox';
251 lb.innerHTML = `
252 <button class="lightbox-close">×</button>
253 <img class="lightbox-img" src="" />
254 `;
255 document.body.appendChild(lb);
256 lb.querySelector('.lightbox-close')!.addEventListener('click', () => lb!.classList.remove('open'));
257 lb.addEventListener('click', (e) => { if (e.target === lb) lb!.classList.remove('open'); });
258 document.addEventListener('keydown', (e) => { if (e.key === 'Escape') lb!.classList.remove('open'); });
259 }
260 (lb.querySelector('.lightbox-img') as HTMLImageElement).src = mainImg.src;
261 lb.classList.add('open');
262 });
263 </script>
264
265 <style is:global>
266 .breadcrumbs { margin-bottom: 2rem; }
267 .breadcrumbs a { color: var(--primary); text-decoration: none; }
268 .product-detail {
269 display: grid;
270 grid-template-columns: 1fr 1fr;
271 gap: 3rem;
272 align-items: start;
273 }
274 .gallery-main {
275 width: 100%;
276 border-radius: 12px;
277 cursor: zoom-in;
278 }
279 .product-detail-image { position: relative; }
280 .product-detail-image .sold-out-badge {
281 position: absolute;
282 top: 12px;
283 right: 12px;
284 background: #c0392b;
285 color: #fff;
286 font-size: 0.85rem;
287 font-weight: 700;
288 text-transform: uppercase;
289 padding: 6px 14px;
290 border-radius: 4px;
291 letter-spacing: 0.5px;
292 z-index: 2;
293 }
294 .gallery-thumbs {
295 display: flex;
296 flex-wrap: wrap;
297 gap: 8px;
298 margin-top: 12px;
299 }
300 .gallery-thumb {
301 width: 64px;
302 height: 64px;
303 object-fit: cover;
304 border-radius: 6px;
305 border: 2px solid transparent;
306 cursor: pointer;
307 transition: border-color 0.2s;
308 }
309 .gallery-thumb:hover { border-color: var(--primary); }
310 .gallery-thumb.active { border-color: var(--primary); opacity: 0.6; }
311 .lightbox {
312 display: none;
313 position: fixed;
314 inset: 0;
315 background: rgba(0,0,0,0.9);
316 z-index: 9999;
317 align-items: center;
318 justify-content: center;
319 }
320 .lightbox.open { display: flex; }
321 .lightbox-img {
322 max-width: 90vw;
323 max-height: 90vh;
324 object-fit: contain;
325 border-radius: 8px;
326 }
327 .lightbox-close {
328 position: absolute;
329 top: 20px;
330 right: 30px;
331 background: none;
332 border: none;
333 color: #fff;
334 font-size: 36px;
335 cursor: pointer;
336 line-height: 1;
337 z-index: 1;
338 }
339 .product-name-en {
340 font-size: 1rem;
341 color: var(--text-light);
342 font-style: italic;
343 margin-bottom: 0.5rem;
344 }
345 .product-price-large {
346 font-size: 1.5rem;
347 font-weight: 700;
348 color: var(--primary);
349 margin: 1rem 0 0;
350 }
351 .product-weight {
352 font-size: 0.9rem;
353 color: var(--text-light);
354 margin: 0 0 0.5rem;
355 }
356 .product-harvest {
357 font-size: 0.9rem;
358 color: var(--text-light);
359 margin-bottom: 0.3rem;
360 }
361 .product-description {
362 margin: 1rem 0 1.5rem;
363 text-align: justify;
364 }
365 .product-description h4 {
366 margin: 0 0 0.5rem;
367 font-size: 0.9rem;
368 color: var(--primary);
369 }
370 .product-stock {
371 font-size: 0.9rem;
372 color: var(--text-light);
373 margin-bottom: 0.5rem;
374 }
375 .product-usage {
376 margin: 0 0 1.5rem;
377 padding: 1rem;
378 background: var(--bg-alt, #f5f5f5);
379 border-radius: 8px;
380 text-align: justify;
381 }
382 .product-usage h4 {
383 margin: 0 0 0.5rem;
384 font-size: 0.9rem;
385 color: var(--primary);
386 }
387 .product-usage p { font-size: 0.9rem; }
388 .product-actions {
389 display: flex;
390 gap: 1rem;
391 align-items: center;
392 }
393 #add-to-cart-btn { flex: 1; }
394 #add-to-cart-btn:disabled { opacity: 0.45; cursor: default; }
395 @media (max-width: 768px) {
396 .product-detail { grid-template-columns: 1fr; }
397 }
398 </style>
399